/** * GET /_emdash/api/auth/oauth/[provider]/callback * * Handle OAuth callback from provider */ import type { APIRoute } from "astro"; export const prerender = false; import { handleOAuthCallback, OAuthError, Role, type OAuthConsumerConfig, type RoleLevel, } from "@premium-cms/auth"; import { createKyselyAdapter } from "@premium-cms/auth/adapters/kysely"; import { getPublicOrigin } from "#api/public-url.js"; import { finalizeSetup } from "#api/setup-complete.js"; import { createOAuthStateStore } from "#auth/oauth-state-store.js"; import { OptionsRepository } from "#db/repositories/options.js"; type ProviderName = "github" | "google"; const VALID_PROVIDERS = new Set(["github", "google"]); function isValidProvider(provider: string): provider is ProviderName { return VALID_PROVIDERS.has(provider); } /** Safely extract a string value from an env-like record */ function envString(env: Record, ...keys: string[]): string | undefined { for (const key of keys) { const val = env[key]; if (typeof val === "string" && val) return val; } return undefined; } /** * Get OAuth config from environment variables */ function getOAuthConfig(env: Record): OAuthConsumerConfig["providers"] { const providers: OAuthConsumerConfig["providers"] = {}; // GitHub const githubClientId = envString(env, "EMDASH_OAUTH_GITHUB_CLIENT_ID", "GITHUB_CLIENT_ID"); const githubClientSecret = envString( env, "EMDASH_OAUTH_GITHUB_CLIENT_SECRET", "GITHUB_CLIENT_SECRET", ); if (githubClientId && githubClientSecret) { providers.github = { clientId: githubClientId, clientSecret: githubClientSecret, }; } // Google const googleClientId = envString(env, "EMDASH_OAUTH_GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_ID"); const googleClientSecret = envString( env, "EMDASH_OAUTH_GOOGLE_CLIENT_SECRET", "GOOGLE_CLIENT_SECRET", ); if (googleClientId && googleClientSecret) { providers.google = { clientId: googleClientId, clientSecret: googleClientSecret, }; } return providers; } export const GET: APIRoute = async ({ params, request, locals, session, redirect }) => { const { emdash } = locals; const provider = params.provider; // Validate provider if (!provider || !isValidProvider(provider)) { return redirect( `/_emdash/admin/login?error=invalid_provider&message=${encodeURIComponent("Invalid OAuth provider")}`, ); } if (!emdash?.db) { return redirect( `/_emdash/admin/login?error=server_error&message=${encodeURIComponent("Database not configured")}`, ); } const url = new URL(request.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); const error = url.searchParams.get("error"); const errorDescription = url.searchParams.get("error_description"); // Handle OAuth errors from provider if (error) { const message = errorDescription || error; return redirect( `/_emdash/admin/login?error=oauth_denied&message=${encodeURIComponent(message)}`, ); } // Validate required params if (!code || !state) { return redirect( `/_emdash/admin/login?error=invalid_callback&message=${encodeURIComponent("Missing code or state parameter")}`, ); } try { // Get OAuth providers from environment. Astro 6 removed // `Astro.locals.runtime.env` (accessing it throws rather than // returning undefined, so optional-chaining doesn't help) -- read // Cloudflare bindings via the emdash virtual module instead, which // re-exports `cloudflare:workers`' `env` under that adapter and // falls back to `import.meta.env` on Node (#1736). // @ts-ignore - virtual module, generated by the Astro integration const { env: cfEnv } = (await import("virtual:emdash/env")) as { env?: Record; }; const env = cfEnv ?? import.meta.env; const providers = getOAuthConfig(env); if (!providers[provider]) { return redirect( `/_emdash/admin/login?error=provider_not_configured&message=${encodeURIComponent(`OAuth provider ${provider} is not configured`)}`, ); } const adapter = createKyselyAdapter(emdash.db); const stateStore = createOAuthStateStore(emdash.db); const config: OAuthConsumerConfig = { baseUrl: `${getPublicOrigin(url, emdash?.config)}/_emdash`, providers, canSelfSignup: async (email: string) => { // During setup: first user becomes admin. // Check setup_complete flag instead of countUsers() to avoid // a TOCTOU race where concurrent callbacks both see 0 users. const options = new OptionsRepository(emdash.db); const setupComplete = await options.get("emdash:setup_complete"); if (setupComplete !== true && setupComplete !== "true") { return { allowed: true, role: Role.ADMIN }; } // Extract domain from email const domain = email.split("@")[1]?.toLowerCase(); if (!domain) { return null; } // Check allowed_domains table for a matching, enabled entry const entry = await emdash.db .selectFrom("allowed_domains") .selectAll() .where("domain", "=", domain) .where("enabled", "=", 1) .executeTakeFirst(); if (!entry) { return null; } // Map the stored role level to the Role enum const roleLevel = entry.default_role; const roleMap: Record = { 50: Role.ADMIN, 40: Role.EDITOR, 30: Role.AUTHOR, 20: Role.CONTRIBUTOR, 10: Role.SUBSCRIBER, }; const role = roleMap[roleLevel] ?? Role.CONTRIBUTOR; if (!roleMap[roleLevel]) { console.warn( `[oauth] Unknown role level ${roleLevel} for domain ${domain}, defaulting to CONTRIBUTOR`, ); } return { allowed: true, role }; }, }; const options = new OptionsRepository(emdash.db); const setupCompleteBefore = await options.get("emdash:setup_complete"); const user = await handleOAuthCallback(config, adapter, provider, code, state, stateStore); const isFirstUser = setupCompleteBefore !== true && setupCompleteBefore !== "true"; // Finalize setup outside the transaction (idempotent, safe if two callbacks race). if (isFirstUser) { await finalizeSetup(emdash.db); console.log(`[oauth] Setup complete: created admin user via ${provider} (${user.email})`); } // Create session if (session) { session.set("user", { id: user.id }); } // Redirect to admin dashboard return redirect("/_emdash/admin"); } catch (callbackError) { console.error("OAuth callback error:", callbackError); let message = "Authentication failed"; let errorCode = "oauth_error"; if (callbackError instanceof OAuthError) { errorCode = callbackError.code; // Map all error codes to user-friendly messages (never expose raw error.message) switch (callbackError.code) { case "invalid_state": message = "OAuth session expired or invalid. Please try again."; break; case "signup_not_allowed": message = "Self-signup is not allowed for your email. Please contact an administrator."; break; case "invite_invalid": message = "This invite link is invalid or has expired. Please ask for a new one."; break; case "invite_email_mismatch": message = "This invite was sent to a different email address than your account."; break; case "invite_email_unverified": message = "Your account's email is not verified by the provider. Please verify it and try again."; break; case "user_not_found": message = "Your account was not found. It may have been deleted."; break; case "token_exchange_failed": message = "Failed to complete authentication. Please try again."; break; case "profile_fetch_failed": message = "Failed to retrieve your profile. Please try again."; break; default: message = "Authentication failed. Please try again."; break; } } // For generic errors, keep the default "Authentication failed" message return redirect( `/_emdash/admin/login?error=${errorCode}&message=${encodeURIComponent(message)}`, ); } };